<?php
/** Rho server-side caching module. 
* If you require caching functionality, require_once this file
* after requiring core.
* Note that other modules that use Rho_Cache will automatically load this file on-demand 
* -it doens't need to be manuelly included in these cases.
*
* @package Rho
* @author Pete
* @version 0.1
* @license http://www.opensource.org/licenses/apache2.0.php Apache License, Version 2.0.*/

/** Provides static methods for server-side caching.
*
* Example: <code>
* $foo=Rho_Cache::load('foos', 'foo1');
* if($foo) {
*    echo $foo;
* }
* else {
*    $foo=createFoo(1);
*    Rho_Cache::save('foos', 'foo1', $foo);
*    echo $foo;
* }
* </code>
*
* This class is never instantated.
* 
* @see Rho_Cache::clear()
* @package Rho */
final class Rho_Cache{

  /** @var Rho_Cache_Driver Instance of cache driver.*/
  private static $_driver;
  
  /** No instances.*/
  private function __construct(){
  
  }
  
  /** Save something to the cache.
  * 
  * Generally you should call Rho_Cache::load() first and check for a non-false result before calling save.
  *
  * @see Rho_Cache::load()
  * @param string $prefix The type of thing being cached, e.g. 'foos'. Should be ASCII lowercase no symbols except . and _ .
  * @param string $oid A unique identifier for the thing to be saved, e.g. '1f3870be274f6c49b3e31a0c6728957f' . Should be ASCII lowercase no symbols except . and _ .
  * @param mixed &$item The thing to save to the cache.
  * @param int $max_age Maximum age in seconds of the item to save the cache.
  * If 0, then the default time is used.
  * You should use the same value of $max_age both for saving and loading items with the same prefix. 
  * @return bool TRUE on sucess. */ 
  public static final function save($prefix, $oid, & $item,  $max_age=0){
    self::_loadDriver();
    $data=serialize($item);
    if($max_age==0){
      $max_age=Rho::conf('cache.default_time', 600);
    }
    return self::$_driver->save($prefix, $oid, $data, $max_age);
  }
  
  /** Load an item from the cache.
  * @param string $prefix The type of thing being cached, e.g. 'foos'. Should be ASCII lowercase no symbols except . and _ .
  * @param string $oid A unique identifier for the thing to be loaded, e.g. '1f3870be274f6c49b3e31a0c6728957f' . Should be ASCII lowercase no symbols except . and _ .
  * @param int $max_age Maximum age in seconds of the item to fetch from the cache.
  * If 0, then the default time is used.
  * You should use the same value of $max_age both for saving and loading items with the same prefix. 
  * @return bool|mixed FALSE if the item is not in the cache, otherwise the item that was saved.
  * @see save() */
  public static final function load($prefix, $oid, $max_age=0){
    self::_loadDriver();
    if($max_age==0){
      $max_age=Rho::conf('cache.default_time', 600);
    }
    $result=self::$_driver->load($prefix, $oid, $max_age);
    if($result==FALSE){
      return FALSE;
    }
    return unserialize($result);
  }
  
  /** Delete an item from the cache.
  * @param string $prefix The type of thing being cached, e.g. 'foos'.
  * @param string $oid A unique identifier for the thing to be loaded, e.g. '1f3870be274f6c49b3e31a0c6728957f' .
  * @return bool TRUE is the item was deleted. FALSE is the item didn't exist, or could not be deleted.
  * @see clear() */
  public static final function delete($prefix, $oid){
    self::_loadDriver();
    return self::$_driver->delete($prefix, $oid);
  }
  
  /** Clear the cache of a group of items.
  *
  * Passing a prefix of 'foos' will delete all items
  * that were previously saved with the prefix 'foos'.
  *
  * Intelligent use of $prefix can allow grouping, e.g. 
  * <code>
  * Rho_Cache::save('foos.widgets', 'widget1', $widget1);
  * Rho_Cache::save('foos.widgets', 'widget2', $widget2);
  * Rho_Cache::save('foos.bars', 'bar1', $my_bar);
  * ...
  * # only delete widgets - bar1 will still exist
  * Rho_Cache::clear('foos.widgets');
  * $bar=Rho_Cache::load('foos.bars', 'bar1');
  * # This will delete all foos
  * Rho_Cahce::clear('foos');
  * </code>
  * 
  * @param string $prefix The type of thing being cached, e.g. 'foos'. 
  * Should be ASCII lowercase no symbols except . and _ .
  * @throws Rho_Exception if prefix is empty.
  * @return bool TRUE on sucess.
  * @see clearAll() */
  public static final function clear($prefix){
    if(empty($prefix) ){
      throw new Rho_Exception('Empty prefix passed to Rho_Cache::clear() - use clearAll instead', 3001);
      return FALSE;
    }
    self::_loadDriver();
    $prefix .= '.';
    return self::$_driver->clear($prefix);
  }
  
  /** Empties the entire cache.
  * @return bool TRUE on sucess.*/
  public static final function clearAll(){
    $prefix='';
    self::_loadDriver();
    return self::$_driver->clear($prefix);
  }
  
  /** Loads the driver specified by cache.type */ 
  private static final function _loadDriver(){
    if(isset(self::$_driver) ) return;
    $type=Rho::conf('cache.type', 'File');
    $class_name='Cache_'.$type.'_Driver';
    if($type != 'File'){
      //load the class
      require_once Rho::$PATH.'rho/cache/'.$class_name.'.class.php';
    }
    self::$_driver=new $class_name();
  }

}

/** Interface for cache drivers.
* @package Rho
* @subpackage Rho-cache */
interface Rho_Cache_Driver{

  /** Save something to the cache.
  * @param string &$prefix The type of thing being cached, e.g. 'foos'.
  * @param string &$oid A unique identifier for the thing to be saved, e.g. '1f3870be274f6c49b3e31a0c6728957f' .
  * @param string &$data The value to save - this is the serialized string of the item.
  * @param int &$max_age Maximum age in seconds to store the item in the cache.
  * @return bool TRUE on sucess.*/ 
  public function save(& $prefix, & $oid, & $data, & $max_age);  

  /** Load an item from the cache.
  * @param string &$prefix The type of thing being cached, e.g. 'foos'.
  * @param string &$oid A unique identifier for the thing to be loaded, e.g. '1f3870be274f6c49b3e31a0c6728957f' .
  * @param int &$max_age Maximum age in seconds of the item to fetch from the cache.
  * @return bool|string FALSE if the item is not in the cache or too old, 
  * otherwise the item that was saved in it's serilaized form.*/
  public function load(& $prefix, & $oid, & $max_age);
  
  /** Delete an item from the cache.
  * @param string &$prefix The type of thing being cached, e.g. 'foos'.
  * @param string &$oid A unique identifier for the thing to be loaded, e.g. '1f3870be274f6c49b3e31a0c6728957f' .
  * @return bool TRUE is the item was deleted. FALSE is the item didn't exist, or could not be deleted.*/
  public function delete(& $prefix, & $oid);
  
  /** Clear the cache of a group of items.
  * @param string &$prefix The type of cached items to delete with a .suffix, e.g. 'foos.'
  * An empty prefix indicates to clear the entire cache.
  * @see Rho_Cache::clear() 
  * @return bool TRUE on success.*/
  public function clear(& $prefix);
}

/** Server-side cache driver that uses the filesystem for caching.
* This is the default cache driver.
* Should not be directly created - use the static methods of Rho_Cache .
* 
* @internal Objects are stored in Rho::$APP_PATH.'cache'
* @see Rho_Cache
* @package Rho
* @subpackage Rho-cache 
* @see Rho_Cache */
class Cache_File_Driver implements Rho_Cache_Driver{

  /** @var string Filename extension including '.' */
  private static $ext='.dat';
  
  /** @var string Error Log prefix string.*/
  private static $log='Cache : File Driver - ';
  
  /** @var string cache path */
  private $_path;
  
  /** @var int Writing flags.*/
  private $_flags=LOCK_EX;
  
  
  /** Constructor*/
  public function __construct(){
    $this->_path=Rho::$APP_PATH.'cache/';
    if (version_compare(PHP_VERSION, '6.0.0') === 1) {
      $this->_flags=(LOCK_EX | FILE_TEXT); # FILE_TEXT is only available from PHPv6 onwards
    }
  }

  public function save(& $prefix, & $oid, & $data, & $max_age){
    $result=@file_put_contents($this->_path.$prefix.'.'.$oid.self::$ext, $data, $this->_flags );
    if($result!==FALSE){
      return TRUE;
    }
    // it's all gone Pete Tong
    Rho_Log::error(3110, self::$log.'Could not write to cache');
    // is it the cache directory, just this file, or both?
    if(! is_writable($this->_path) ){
      Rho_Log::error(3120, self::$log.'Cache directory is not writable');
    }
    $filename=$this->_path.$prefix.'.'.$oid.self::$ext;
    if(! is_writable($filename) ){
      Rho_Log::error(3130, self::$log.'File '.$filename.' is not writable');
    }
    return FALSE;
  }
  
  public function load(& $prefix, & $oid, & $max_age){
    $filename=$this->_path.$prefix.'.'.$oid.self::$ext;
    if(! @file_exists($filename) ){
      return FALSE;
    }
    // check time
    $stats=@lstat($filename);
    if( ! $stats){
      Rho_Log::error(3150, self::$log.'Could not stat '.$filename);
      return FALSE;
    }
    if($stats['mtime'] < (time() - $max_age) ){
      // too old
      $this->_delete($filename); # we use the private method as it's quicker
      return FALSE;
    }
    // slurp in the file
    $result=@file_get_contents($filename, FILE_TEXT);
    if($result!==FALSE){
      return $result;
    }
    // somethings gone wrong, but what?
    Rho_Log::error(3160, self::$log.'Could not read from '.$filename);
    if (! is_readable($filename) ){
      Rho_Log::error(3165, self::$log.'File '.$filename.' is not readable');
    }
    elseif (! is_readable($this->_path) ){
      Rho_Log::error(3170, self::$log.'Cache directory is not readable');
    }
    
    return FALSE;
  }
  
  public function delete(& $prefix, & $oid){
    $filename=$this->_path.$prefix.'.'.$oid.self::$ext;
    if(@file_exists($filename) ){
      return $this->_delete($filename);
    }
    return FALSE;
  }
  
  /** Deletes a file.
  * Won't check is it exists or not first.
  * @param string $filename Full path to the cache file.
  * @return bool TRUE on sucess.*/
  private function _delete(& $filename){
    if(@unlink($filename)){
      return TRUE;
    }
    // it didn't work
    // maybe it's been deleted by another request somewhere?
    if(! @file_exists($filename) ){
      Rho_Log::info(self::$log.$filename.' does not exist anymore in call to _delete()');# debug logging
      return FALSE;
    }
    // ok, file still exists but we can't get rid of it
    Rho_Log::error(3180, self::$log.'Could not delete file '.$filename);
    // is it to do with permissions?
    if(! is_writable($this->_path) ){
      Rho_Log::error(3120, self::$log.'Cache directory is not writable');
    }
    elseif(! is_writable($filename) ){
      Rho_Log::error(3130, self::$log.'File '.$filename.' is not writable');
    }
    return FALSE;
  }
  
  public function clear(& $prefix){
    $files=glob($this->_path.$prefix.'*'.self::$ext);
    if(! $files){
      Rho_Log::error(3190, self::$log.' Cannot open cache directory for clear()');
    }
    $result=TRUE;
    foreach($files as $file) :
      if(is_file($file) ){
        if(! @unlink($file) ){
          Rho_Log::error(3191, self::$log." Cannot remove $file for clear()");
          $result=FALSE;
        }
      }
    endforeach;
    return $result;
  }
}